iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
自我挑戰組

韌體工程師的不只 0x10 個問題系列 第 24

Day 24-資料競爭的解決方法

  • 分享至 

  • xImage
  •  

Day 23 的文章講到如果多執行緒共用同一個記憶體資源,且至少一個執行緒有寫入行為的話,會導致資料競爭,跑出不在預期中的結果,本篇文章就要來探討解決方法。

第一招:互斥鎖 Mutex

Mutex 的全名是 mutual exclusion(互斥),指當某一個執行緒把持著特定資源時,就把該資源上鎖,其他執行緒要等該執行緒把資源釋出後才能使用。

Day 23 文章延伸,傳統上鎖的寫法如下:

#include <iostream>
#include <thread>
#include <mutex> // 引入互斥鎖標頭檔

int counter = 0;
std::mutex mtx;  // 宣告一個全域互斥鎖 mutex 物件

void addOneManyTimes() {
    mtx.lock(); // 為「臨界區段」上鎖,僅開放一個執行緒入內
    
    for (int i = 0; i < 100000; ++i) {
        ++counter;
    }
    
    mtx.unlock(); // 為「臨界區段」解鎖,使其他等待中的執行緒也能入內
}

int main() {
    std::thread t1(addOneManyTimes);
    std::thread t2(addOneManyTimes);

    t1.join();
    t2.join();

    std::cout << "counter = " << counter << '\n';
    return 0;
}

上鎖與解鎖之間的區塊稱為「臨界區段(critical section)」,也就是存取共用資源、但同一時間不允許複數個執行緒同時操作該資源的區塊。

{
    mtx.lock(); // 上鎖
    
    // 臨界區段 critical section
    
    mtx.unlock(); // 解鎖
}

進入臨界區段前以 mtx.lock(); 上鎖、離開臨界區段時再以 mtx.unlock(); 解鎖。

但這樣寫其實有個風險:如果臨界區段邏輯複雜,出現 return 或因為錯誤等因素跳出 {} 區塊,那 mtx.unlock(); 就永遠不會執行到,意即永遠不會解鎖。

lock_guard 避免忘記解鎖

從 C++ 11 起,可以把互斥鎖丟給 std::lock_guard 管理,當控制流程離開 {} 就會自動解鎖,其語法為:

std::lock_guard<std::mutex> name(mutex);
  • name:為這個鎖取的名字。
  • mutex:使用的互斥鎖種類。

所以上述範例可以改成:

std::mutex mtx; // 使用的互斥鎖物件

// ...

void addOneManyTimes() {
    std::lock_guard<std::mutex> lock(mtx); // 新的上鎖方法,取用 mtx 物件、命名為 lock
    
    // 臨界區段 critical section
    for (int i = 0; i < 100000; ++i) {
        ++counter;
    }
    
} // 離開 {} 時會自動解鎖,不用另外寫 mtx.unlock()

lock_guard 更強大的還有 unique_lock,不只一樣會在控制流程離開 {} 就自動解鎖,還能手動調控上鎖與解鎖的時間,當程式邏輯複雜時,有更高的操作彈性,詳細說明可參考 C++ 文件

第二招:原子操作 Atomic Operation

Day 23 文章中有提到資料競爭的原因,為兩執行緒都有以下三步驟:

  1. 讀取。
  2. 加 1。
  3. 寫入。

任一執行緒都沒有等另一個執行緒完成寫入後,就以讀取到的舊值運算,導致有執行緒的第 2. 步被吃掉。

那為何不把這三步都合併成「一個不可拆分的步驟」?這樣如果一執行緒沒有做完三步驟,另一執行緒就沒辦法讀到共用變數 counter,自然也就不會造成資料競爭。

自 C++ 11 起,讓一變數具備原子特性的語法為:

std::atomic<type> name(initial_value);

其中:

  • type :該變數資料型別。
  • name :該變數名稱。
  • initial_value :該變數起始值。

Day 23 範例延伸,以原子操作改寫的程式碼如下:

#include <iostream>
#include <thread>
#include <atomic>  // 引入原子操作標頭檔

std::atomic<int> counter(0); // 型別為 int 的變數 counter 初始化為 0,且具備原子特性

void addOneManyTimes() { // 不用特別上鎖
    for (int i = 0; i < 100000; ++i) {
        ++counter;  // 每次都要完成讀取、+1、寫入三步驟,一氣呵成、不可分割
    }
}

int main() {
    std::thread t1(addOneManyTimes);
    std::thread t2(addOneManyTimes);

    t1.join();
    t2.join();

    std::cout << "counter = " << counter << '\n'; 
    return 0;
}

第三招:條件變數 Conditional Variables

這招相對複雜一些,要另外令一個作為「旗標(flag)」的新變數,兩執行緒透過旗標的值決定是否執行。

Day 23 文章範例來說,可先加上名為 ready 的旗標,旗標的值為 false 時繼續等待、為 true 時再開始執行,整體程式碼如下:

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable> // 加上條件變數標頭檔

int counter = 0;  
bool ready = false; // 旗標初始值為 false

std::mutex mtx;  // 互斥鎖
std::condition_variable cv;  // 條件變數,讓執行緒等待或接收通知

void addOneManyTimes() {  // 離開這個區塊時,mutex 會自動解鎖
    {
        std::unique_lock<std::mutex> lock(mtx);   // 先把這區塊鎖住
        std::cout << "Thread waiting...\n";
        cv.wait(lock, [] { return ready; });      // 當 ready == false 時,就等待;ready == true 時,就跳出此區塊往下執行 ++counter
    }                                             // 離開這個區塊時,mutex 會自動解鎖
    
    // 用 mutex 保護 counter 變數,讓其完成指定次數的 ++counter
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(mtx);
        ++counter;
    }
}

int main() {
    std::thread t1(addOneManyTimes);
    std::thread t2(addOneManyTimes);

    {
        std::lock_guard<std::mutex> lock(mtx);    // 在此區塊上鎖保護 ready 旗標
        ready = true;                             // 把旗標改成 true,才可以讓新執行緒開始工作
        std::cout << "Main thread sends notification.\n";
    }

    cv.notify_all();                              // 喚醒所有正在等待的執行緒

    t1.join();
    t2.join();

    std::cout << "counter = " << counter << '\n';
    return 0;
}

上述程式碼的執行順序如下:

  1. 在主程式 main() 內,t1t2 兩個新執行緒都去執行 addOneManyTimes() 函數:
std::thread t1(addOneManyTimes);
std::thread t2(addOneManyTimes);
  1. t1t2 檢查 ready 旗標,若為 false 則等待被喚醒、若為 true 則往下執行:
// 在 t1 與 t2 各自執行的 addOneManyTimes() 函數中
{
    std::unique_lock<std::mutex> lock(mtx);  // 先把這區塊上鎖,保護 ready 旗標
    std::cout << "Thread waiting...\n";
    cv.wait(lock, [] { return ready; });   // ready 若回傳 false 會先等待,等待期間暫時解鎖,讓 ready 可被其他執行緒改動,直到被喚醒後再上鎖、判斷 ready 新值
                                           // ready 若回傳 true 則不再等待、往下執行
}   // 退出時 {} 會解鎖
  1. 主執行緒 main() 把旗標改為 true
{
    std::lock_guard<std::mutex> lock(mtx);  // ready 為共享變數,因此改變旗標前先上鎖
    ready = true;  // 把旗標改為 true
    std::cout << "Main thread sends notification.\n";
}  // 改完旗標、跳出此區塊後自動解鎖
  1. 改完旗標後,去喚醒所有因為 cv.wait(...) 而正等待中的執行緒:
cv.notify_all();

值得留意的是,如果 t1t2 沒有早於主執行緒先跑,那 t1t2 讀取到的 ready 值直接就是 true,沒有進入等待狀態就進入 ++counter 運算,不需要等主程式把 readyfalse 變為 true 後被喚醒。

  1. 等待中的執行緒被喚醒,若此次執行緒讀取到 ready 旗標變為 true 時,進到下個區塊進行運算;因為運算時使用的 counter 為共享變數,因此還會再上鎖一次:
for (int i = 0; i < 100000; ++i) {
    std::lock_guard<std::mutex> lock(mtx); // 上鎖保護 counter 變數
    ++counter;
}

t1t2 誰先被喚醒不一定,但因為有 lock_guard 保護,所以可確保不會互相影響 counter 的運算結果,最後就是透過:

t1.join();
t2.join();

等兩個新執行緒做完,再顯示 counter 經兩執行緒運算後的最終結果。含前面 cout 的整體顯示結果如下:

Thread waiting...
Thread waiting...
Main thread sends notification.
counter = 200000

Day 22 文章所說,執行緒的執行順序並不一定,因此也可能是下列兩種結果:

Main thread sends notification.
Thread waiting...
Thread waiting...
counter = 200000
Thread waiting...
Main thread sends notification.
Thread waiting...
counter = 200000

無論如何,counter 的值一定是 200000,且一定是最後一行。

參考資料

  1. Data Races in C++ (GeeksforGeeks)
  2. Mutex in C++ (GeeksforGeeks)
  3. std::lock_guard (C++ 參考手冊)
  4. Critical Section in Synchronization (GeeksforGeeks)
  5. unique_lock or lock_guard: Which Is Better? (GeeksforGeeks)
  6. C++ 11 - Header (GeeksforGeeks)
  7. Condition Variables in C++ Multithreading (GeeksforGeeks)

上一篇
Day 23-資料競爭
下一篇
Day 25-解決資料競爭,不能解決競賽條件
系列文
韌體工程師的不只 0x10 個問題31
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言